logo
menu

useQuery 구현하기 2탄 | 커스텀 useQuery 구현

2023. 10. 08.

  • #리액트

useQuery 구현하기 1탄에서 이어지는 글입니다.
 

📄 사전 지식

useQuery 를 구현하기 전에 직접 구현할 useQuery 에서 사용할 정보에 대해 간단하게 알아보자
const { data, // 응답 데이터 error, // 에러 내용 isError, // 에러 상태 isLoading, // 로딩 상태 isSuccess, // 성공 상태 refetch, // refetch 함수 } = useQuery({ queryKey, queryFn, // fetcher cacheTime, // 얘도 있으면 좋을듯! enabled, // 얘도 있으면 좋을듯! onError, onSettled, onSuccess, suspense, // useErrorBoundary, // 얘랑 같이 사용하는 듯! suspense 로 })
위와 같이 react-query 의 useQuery 는 정의되어 있다.
이번에 구현할 useQuery 는 위 Interface 와 유사하지만 약간 다르게 정의해서 사용했다
 

커스텀 useQuery Interface

const { isLoading, // 로딩 상태 data, // 응답 데이터 error, // 에러 내용 } = useQuery(queryFn, { queryKey, enabled, onSuccess, onError, suspense, })
위와 같이 적용했다.

🚀 구현한 UI

notion image
  • useQuery 를 이용해서 총 2개의 API 를 요청해서 데이터를 렌더링한다.
    • 여러 개의 Image List 를 요청후 렌더링하고
    • 해당 아이템을 클릭하면 선택한 아이템의 정보를 요청해서 렌더링하는 구조이다.
 

👨🏻‍💻 간단한 useQuery 구현하기

💡
자세한 코드는 여기를 참고 (캐시까지 적용된 코드)
1탄에서 구현한 useFetch 를 기반으로 useQuery 를 구현하였다!
import { useCallback, useEffect, useState } from 'react'; export function useQuery<T>(fetcher: () => Promise<T>, { queryKey, ...option }: UseQueryOption<T>) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [data, setData] = useState<T>(); const { enabled = true } = option; const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); useEffect(() => { if (!enabled) return; fetch(); }, [enabled, JSON.stringify(queryKey)]); return { isLoading: isLoading, data, error, }; } type UseQueryOption<T> = { queryKey: unknown[]; enabled?: boolean; onSuccess?: (data?: T) => void; onError?: (error?: unknown) => void; };

핵심 로직 1 - fetch 함수

const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]);
  • 인자로 fetcher 를 받아 loading 중인지, 성공 실패 인지에 따라 data 및 error 업데이트 하는 로직을 처리한다.
    • ⇒ 1탄에서 useFetch 의 기본 내용과 비슷하다
  • 다른 점이 있다면 option 으로 전달 받은 onSuccess 와 onError 를 호출하여 처리할 수 있게 하였다.
 

핵심 로직 2 - fetch 함수를 호출하는 기준!

useEffect(() => { if (!enabled) return; fetch(); }, [enabled, JSON.stringify(queryKey)]);
  • option 으로 enabled 를 받아서 enabled 가 true 인 경우만 호출하도록 하였다.
  • 또한, queryKey 가 달라지면 호출되어야 하기에 queryKey 도 의존성으로 추가했다.
    • ⇒ 이때 queryKey가 배열로 오기 때문에 JSON.stringify 로 변경하여 변경 여부를 좀 더 쉽게 처리했다.
 

👨🏻‍💻 캐시 적용

캐시 코드

export class Cache<Key, Value> { private cache: Map<Key, Value>; constructor() { this.cache = new Map<Key, Value>(); } public set(key: Key, value: Value): void { this.cache.set(key, value); } public get(key: Key): Value | undefined { return this.cache.get(key); } public has(key: Key): boolean { return this.cache.has(key); } public delete(key: Key): void { this.cache.delete(key); } // ... }
  • 캐시를 사용하기 위해 캐시를 클래스로 구현하였다.
    • 이 중에 get, set, has 만 사용 하였다

캐시를 적용한 useQuery Hook

import { useCallback, useEffect, useState } from 'react'; import { Cache } from '@/example2/utils/cache.ts'; export function useQuery<T>(fetcher: () => Promise<T>, { queryKey, ...option }: UseQueryOption<T>) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [data, setData] = useState<T>(); const { enabled = true } = option; const [cache] = useState(new Cache<string, T>()); const 쿼리키 = JSON.stringify(queryKey); const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); cache.set(쿼리키, data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); useEffect(() => { if (!enabled) return; if (cache.has(쿼리키)) return; fetch(); }, [enabled, 쿼리키]); const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, }; } type UseQueryOption<T> = { queryKey: unknown[]; enabled?: boolean; onSuccess?: (data?: T) => void; onError?: (error?: unknown) => void; };

핵심 로직 1 - 캐시

const [cache] = useState(new Cache<string, T>()); const 쿼리키 = JSON.stringify(queryKey);
  • Cache 클래스를 이용하여 cache 를 정의하고 이를 useState 로 상태로 관리했다.
    • 해당 구문을 모듈단 (useQuery 커스텀 훅 외부)에서 가능하지만
    • Cache 클래스에 타입을 지정하고자 했고
      • useQuery 외부에 선언시 타입 지정(추론) 불가
    • react 에서 상태로 관리하는게 더 적합하다고 생각했다.
  • 전달 받는 queryKey 는 배열형태이지만, 캐시와 의존성에서 배열보다는 문자열로 관리하는게 더 간편하고 해당 부분을 자주 사용하여 queryKey 를 문자열로 변환하여 쿼리키 변수로 관리했다.
 

핵심 로직 2 - 캐시 적용 및 서비스 로직

const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { const data = await fetcher(); setData(data); cache.set(쿼리키, data); option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); useEffect(() => { if (!enabled) return; if (cache.has(쿼리키)) return; fetch(); }, [enabled, 쿼리키]); const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, };
  • fetch 함수 안에서는
    • 성공한 경우 해당 data 를 cache 에 저장하여 재요청시 캐시 값을 사용할 수 있도록 설정했다.
      const data = await fetcher(); setData(data); cache.set(쿼리키, data);
  • useEffect() 에서 이미 cache 가 있는 경우 새로 API 를 요청하지 안도록 설정했다,
    • if (cache.has(쿼리키)) return;
  • 값을 조회할 때 캐시된 데이터가 있다면 해당 데이터를 먼저 반환하도록 하였다.
    • const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, };
 

캐시 적용 결과 화면

기존에는 캐시가 걸려있지 않아 모든 요청마다 API 를 호출하였는데 캐시가 걸린 경우 API 를 따로 호출하지 않는 것을 확인할 수 있다.
notion image
  • 해당 결과물을 확인하면 처음 요청시에는 API 를 통해 데이터를 호출하고 (1, 2)
    • 이후에 다시 클릭하면 (1, 2) API 요청을 하지 않고 기존에 있는 데이터를 사용한다.
      그 후 새로운 API (5) 를 요청하면 최초이기 때문에 해당 데이터를 다시 요청하고
 
 
 

🚀 Suspense / ErrorBoundary 적용

리액트 18 부터 Suspense 와 ErrorBoundary 를 지원하고 있다 하지만, axios 를 사용해서 Suspense 를 사용하기 어려운데, 왜 안되는지 그리고 이를 어떻게 하면 사용할 수 있는지를 useQuery 를 구현해 보면서 알아보자
 
React 의 Suspense를 사용하면 컴포넌트의 랜더링을 어떤 작업이 끝날 때까지 잠시 중단시키고 다른 컴포넌트를 먼저 랜더링할 수 있다
⇒ 일반적으로 API 요청하는 컴포넌트가 있는 경우 API 요청시에는 로딩 UI (다른 컴포넌트를 먼저 렌더링) 를 먼저 렌더링하고 요청이 완료되면 해당 컴포넌트를 렌더링하도록 Suspense 를 통해 구현할 수 있음!
 

구현

💡
자세한 코드는 여기를 참고

PromiseWrapper

  • utils/promiseWrapper.ts
/** * @link https://deadsimplechat.com/blog/react-suspense/ */ export function promiseWrapper<T>(promise: Promise<T>) { let status = 'pending'; let result: T; const s = promise.then( (value) => { status = 'success'; result = value; }, (error) => { status = 'error'; result = error; }, ); return () => { switch (status) { case 'pending': throw s; case 'success': return result; case 'error': throw result; default: throw new Error('Unknown status'); } }; }
  • React Suspens 와 함께 작동하도록 Axios 요청을 래핑하는 유틸리티 메서드를 만들었다
  • pending 상태
    • Promise 자체를 반환해서 Suspense가 이를 보고 fallback component 를 반한하도록 Promise 를 반환하도록 구성
      success 상태
      Promise 의 반환 값을 반환한다.
      error 상태
      에러를 반환
💡
이로 인해 Suspense 는 성공이면 Promise 반환 값을, 로딩이면 Promise 가 반환되기에 Suspense 에서 처리될 수 있고, error 인 경우 Error 가 반환되어 ErrorBoundary 에서 처리될 수 있다.
 

useQuery

  • exmple3/hooks/useQuery.ts
import { useCallback, useEffect, useState } from 'react'; import { Cache } from '../utils/cache'; import { promiseWrapper } from '../utils/promiseWrapper.ts'; export function useQuery<T>(fetcher: () => Promise<T>, { queryKey, ...option }: UseQueryOption<T>) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState<Error | null>(null); const [data, setData] = useState<T>(); const { enabled = true, suspense = false } = option; const [cache] = useState(new Cache<string, T>()); const 쿼리키 = JSON.stringify(queryKey); // 캐시 - 일반인 경우 useEffect(() => { if (!enabled) return; if (cache.has(쿼리키)) return; fetch(); }, [enabled, 쿼리키]); // suspense 관련 로직 useEffect(() => { if (!enabled) return; if (!suspense) return; if (!data) return; if (cache.has(쿼리키)) return; cache.set(쿼리키, data); // 위 useEffect 에서 쿼리키에 따라 호출 여부를 판단했고 // 해당 부분은 suspenseWrapper 에서 올바른 결과값을 할당하기 위해 처리 }, [data]); const fetch = useCallback(async () => { setIsLoading(true); setError(null); try { if (suspense) { // suspense 처리 로직 const promise = fetcher(); setData(promiseWrapper(promise)); } else { // 일반 로직 - suspense 처리 로직 이 아닌 경우 const data = await fetcher(); setData(data); cache.set(쿼리키, data); } option.onSuccess && option.onSuccess(); } catch (error) { if (error instanceof Error) { setError(error); } option.onError && option.onError(error); } finally { setIsLoading(false); } }, [fetcher]); const cachedData = cache.get(쿼리키); return { isLoading: isLoading, data: cachedData ?? data, error, }; } type UseQueryOption<T> = { queryKey: unknown[]; enabled?: boolean; onSuccess?: (data?: T) => void; onError?: (error?: unknown) => void; suspense?: boolean; };
  • 기존 코드와 크게 달라진것 없이 suspens 를 처리하기 위한 props 및 관련 로직을 추가했다.
  • 그리고 suspense 인 경우 처리하기 위해 useEffect 와 fetch 함수에 로직을 추가했다.
    • // suspense 관련 로직 useEffect(() => { if (!enabled) return; if (!suspense) return; if (!data) return; if (cache.has(쿼리키)) return; cache.set(쿼리키, data); // 위 useEffect 에서 쿼리키에 따라 호출 여부를 판단했고 // 해당 부분은 suspenseWrapper 에서 올바른 결과값을 할당하기 위해 처리 }, [data]);
    • suspense 인 경우에 동작하기 위해 추가했다.
      • fetch 함수에서 setData(promiseWrapper(promise)); 와 같이 promiseWrapper 에서 정상적인 경우 응답 data 를 반환하기에 의존성을 줘서 올바른 data 가 올 경우 cache 에 등록하도록 하였다.
      const fetch = useCallback(async () => { // ... try { if (suspense) { // suspense 처리 로직 const promise = fetcher(); setData(promiseWrapper(promise)); } else { // ... }, [fetcher]);
    • 기존 fetch 함수에서는 suspense 를 고려하지 않아 suspense 인 경우와 그렇지 않은 경우 로직을 추가 처리했다.

여기까지 useQuery 를 간단하게 구현하고 캐시 및 Suspense 까지 적용해봤다.
추가적으로 ErrorBoudary 도 적용해 볼 수 있다. ErrorBoundary 도 PromiseWrapper 에서 error 인 경우도 처리되어있기 때문에 위 코드를 잘 이용해서하면 쉽게 구현할 수 있다.
 
이제 마지막으로 useInfiniteQuery 를 간단하게 구현해보자!
 

참고